Skip to content

feat(forge): revert diagnostic inspector #10446

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
May 22, 2025

Conversation

0xrusowsky
Copy link
Contributor

@0xrusowsky 0xrusowsky commented May 6, 2025

closes

design

Created a new RevertDiagnostic inspector that provides detailed information of otherwise undefined evm reverts. For the time being, the inspector suports:

#[derive(Debug, Clone, Copy)]
pub enum DetailedRevertReason {
    CallToNonContract(Address),
    DelegateCallToNonContract(Address),
}

Additionally, enhanced the traces decoder to determine when a function call to an unkown selector takes places (only for local contracts with artifacts).

output

these would be the new error messages that users would see:

Traces:
  [6350] NonContractCallRevertTest::test_non_contract_call_failure()
    ├─ [0] console::log("test non contract call failure") [staticcall]
    │   └─ ← [Stop]
    ├─ [0] 0xdEADBEeF00000000000000000000000000000000::number()
    │   └─ ← [Stop]
    └─ ← [Revert] call to non-contract address 0xdEADBEeF00000000000000000000000000000000
    // rest of the logs

Traces:
  [255303] NonContractDelegateCallRevertTest::test_unlinked_library_call_failure()
    ├─ [0] console::log("Test: Simulating call to unlinked library") [staticcall]
    │   └─ ← [Stop]
    ├─ [214746] → new LibraryCaller@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
    │   └─ ← [Return] 960 bytes of code
    ├─ [3896] LibraryCaller::foobar(10)
    │   ├─ [0] 0xdEADBEeF00000000000000000000000000000000::foo(10) [delegatecall]
    │   │   └─ ← [Stop]
    │   └─ ← [Revert] delegatecall to non-contract address 0xdEADBEeF00000000000000000000000000000000 (usually an unliked library)
    // rest of the logs

Traces:
  [8620] NonContractCallRevertTest::test_non_supported_selector_call_failure()
    ├─ [0] console::log("test non supported fn selector call failure") [staticcall]
    │   └─ ← [Stop]
    ├─ [145] Counter::random()
    │   └─ ← [Revert] unrecognized function selector 0x5ec01e4d for contract 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f, which has no fallback function.
    // rest of the logs

benchmarks forge test -vvvv

Project Before [v1.2.1] After Overhead
morpho-blue 1.93s 2.14s 10.8%
spark-psm 39.56s 44.88s 13.4%
uniswap/v4-core 6.93s 7.72s 11.4%
vectorized/solady 2.44s 2.67s 9.4%

notes:

  • benchmarks only measure running of tests and are pre-build
  • benchmarks were run on an m2pro with 16gb of RAM
  • each test run 5 times and the reported time is the avg

conclusion: the new inspector makes tests (on average) ~11% slower... the bright side is that it would only impact those test runs where traces are active (which i would assume to only be when actively debugging).

do we feel like this is acceptable?

@0xrusowsky 0xrusowsky linked an issue May 6, 2025 that may be closed by this pull request
Copy link
Collaborator

@grandizzy grandizzy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO on the right track, @klkvr pls share your thoughts re the approach.
left some minor comments / nits.
To note that such default option could add perf penalty for invariant tests running with fail_on_revert = false but probably bearable.

outcome: CallOutcome,
) -> CallOutcome {
if outcome.result.result == InstructionResult::Revert {
self.reverted = true
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe here we could directly set the reason? we can also do more checks if target address is actually a contract, (like checking if selector available, etc. some other similar hh checks https://github.com/NomicFoundation/hardhat/blob/67f1e95e1f3904f7b2e8a5560115c1551e899f64/packages/hardhat-core/src/internal/hardhat-network/stack-traces/solidity-errors.ts#L218-L341)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i thought that storing a bool could be more efficient + fn reason() would allow us to isolate the logic, but totally fine for me to do it directly

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need some smarter strategy here to ensure that call we've caught in call hook is indeed the one that caused revert (e.g compare depth, ensure there were no calls after it)

Comment on lines 3612 to 3614
interface ICounter {
function number() external returns (uint256);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that in cases of calling functions returning data Solidity's approach is to revert if data is empty.

However, if method does not return anything (e.g like increment()) then Solidity would firstly check before the call whether the address has any code, and revert if it's not which I believe is not being caught by our inspector rn

@0xrusowsky 0xrusowsky requested review from grandizzy and klkvr May 15, 2025 15:04
@0xrusowsky
Copy link
Contributor Author

addresses the PR feedback by checking the call stack depth + tracking EXTCODESIZE calls to empty addresses (confirmed that the initial impl wouldn't diagnose those).

the only downside is extra performance overhead, but i tried to make the fn setp as efficient as possible (improvement suggestions are welcome)

@0xrusowsky 0xrusowsky marked this pull request as ready for review May 16, 2025 10:48
@0xrusowsky
Copy link
Contributor Author

i also enhanced the trace decoder to identify function calls for a selector that is not supported by the called contract (if the tracer has access to its abi)

Copy link
Collaborator

@grandizzy grandizzy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm! one comment re enabling diagnostics and tracing, pls check

@grandizzy grandizzy changed the title chore(forge): revert diagnostic inspector feat(forge): revert diagnostic inspector May 19, 2025
@grandizzy grandizzy added T-feature Type: feature C-forge Command: forge labels May 19, 2025
@grandizzy grandizzy self-requested a review May 19, 2025 08:57
grandizzy
grandizzy previously approved these changes May 19, 2025
Copy link
Collaborator

@grandizzy grandizzy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense, thank you! pending others review before merging
CC @klkvr

zerosnacks
zerosnacks previously approved these changes May 19, 2025
Copy link
Member

@zerosnacks zerosnacks left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm 👍 pending final review from @klkvr

Note: there will likely be a small merge conflict after I merge #10183

@0xrusowsky
Copy link
Contributor Author

lgtm 👍 pending final review from @klkvr

Note: there will likely be a small merge conflict after I merge #10183

great, thanks!
np, i'll take care of that once it shows up 🤝

Copy link
Member

@klkvr klkvr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very nice! overall the approach lgtm, this feature is kind of tricky because we're basically forced to do bytecode/execution pattern matching and I'd like us to reduce false positives here and make sure we document this clearly because this logic will likely get more complex over time

Comment on lines 375 to 383
// Check if unsupported fn selector: calldata dooes NOT point to one of its selectors +
// non-fallback contract + no receive
if let Some(contract_selectors) = self.non_fallback_contracts.get(&trace.address) {
if !contract_selectors.contains(&selector) &&
(!cdata.is_empty() || !self.receive_contracts.contains(&trace.address))
{
let return_data = if !trace.success {
let revert_msg =
self.revert_decoder.decode(&trace.output, Some(trace.status), None);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will only work for local contracts right? I'm wondering if there's some common bytecode pattern that is being reached when contract is called with such wrong calldata which we could catch and make this logic work for any contract for which we don't know abi

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, exactly this would only provide insights if we know the abi beforehand

i guess a generic approach would be to perform some sort bytecode analysis and identify when the PC doesn't do any jumps on the function dispatcher and ends up reverting (idk if this vyper uses the same approach as solidity though)

@0xrusowsky 0xrusowsky dismissed stale reviews from zerosnacks and grandizzy via e0c5f9e May 20, 2025 21:24
@0xrusowsky 0xrusowsky requested a review from klkvr May 20, 2025 23:05
@0xrusowsky
Copy link
Contributor Author

0xrusowsky commented May 20, 2025

@klkvr i've addressed all the feedback that u left:

  • improved docs
  • made things more restrictive by enforcing "empty revert data" as a condition
  • injected the revert reason into the inspector --> to do this, i had to move the fn call_end() logic into fn step()

lmk if i should fix something else 🙂

PS: as discussed, we'd leave the trace decoder improvement (where we would analyze that there are no matches in the fn dispatcher) for another PR

klkvr
klkvr previously approved these changes May 21, 2025
Copy link
Member

@klkvr klkvr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, only have small nits

Comment on lines 141 to 145
if let Ok(state) = ctx.journal().code(target) {
if state.is_empty() && !inputs.input.is_empty() {
self.non_contract_call = Some((target, inputs.scheme, ctx.journal().depth()));
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: pattern CALL -> REVERT actually means that Solidity was not able to decode outputs which might happen even when contract has code but reported with invalid abi encoding like in e.g common case of ERC20 transfer not returning bool for some tokens. Ideally we should also be able to catch this case but we can track this separately

Copy link
Contributor Author

@0xrusowsky 0xrusowsky May 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'll open a new issue for that one and the unsupported selector.

just to be certain that i don't miss anything:

  1. missmatch between bytecode and assumed abi: we can catch it cause there is a successful call, followed by a revert at the same depth where the call took place --> to avoid false positives, we should know check the returndata validation logic, but that requires careful inspection of the opcodes following the call. any hints on what u'd do exactly?
  2. call to non supported fn selector: we can catch it (at least assuming that the target is a solidity/vyper contract) if, when a call takes places, there is a revert without any previous positive JUMPI, as that would mean that the PC walks through the whole fn dispatcher without finding a match --> using the traces decoder is limited to local contracts, but will never throw false positives. with this logic we could do everything with the inspector alone, which i like. however, do you think the approach is sound enough?

@0xrusowsky
Copy link
Contributor Author

@grandizzy finally ready, with @klkvr last round of feedback incorporated!

Copy link
Collaborator

@grandizzy grandizzy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you, good to send it from my pov! We should make some comparison with the project we use to have as a baseline (see #10183 (comment)) to be aware of penalty it introduce when forge test with traces.

@klkvr @zerosnacks @DaniPopes last pass through before merge please, thank you!

Copy link
Member

@zerosnacks zerosnacks left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm!

@klkvr klkvr merged commit 4332dc4 into foundry-rs:master May 22, 2025
22 checks passed
@github-project-automation github-project-automation bot moved this to Done in Foundry May 22, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
C-forge Command: forge T-feature Type: feature
Projects
Status: Done
Development

Successfully merging this pull request may close these issues.

Better error reporting when interacting with undeployed contracts
4 participants